def _impl(ctx):
    if (ctx.attr.name != "node_modules"):
        fail("Target must be called node_modules")
    tree = ctx.actions.declare_directory(
        ctx.attr.name
        if not ctx.attr.for_subdirectory
        else ctx.attr.for_subdirectory + "/" + ctx.attr.name
    )
    folder_path = tree.path.replace("/node_modules", "")

    #   NPM refuses to install into node_modules directly as it's a strange
    #       symlinky virtual file system.
    #   Similarly, it won't use a cache folder in this directory, so we stage
    #       things in /tmp folders then copy back across.
    #   We override the install directory with --prefix, changing directory
    #       doesn't work due to the virtual file system issues.
    #   We have to be careful to clean up directories and make sure the workdirs
    #       are unique to avoid caching issues as there is no way to turn off the cache.
    packages = []
    for d in ctx.attr.deps:
        for f in d.files.to_list():
            # We are going to define $ROOT in the command below to get absolute paths.
            packages.append("$ROOT/" + f.path)
    for p in ctx.attr.packages:
        packages.append(p)
    ctx.actions.run_shell(
        tools = [ctx.executable._tool],
        inputs = ctx.files.deps,
        outputs = [tree],
        command = """
          BIN_PATH=$PWD/{npm};
          ROOT=$PWD;
          mkdir -p /tmp/.bazel-node-modules/{folder_path}/workdir;
          mkdir -p /tmp/.bazel-node-modules/{folder_path}/cache;
          rm -rf /tmp/.bazel-node-modules/{folder_path}/workdir/*;
          rm -rf /tmp/.bazel-node-modules/{folder_path}/cache/*;
          $BIN_PATH \\
              --prefix /tmp/.bazel-node-modules/{folder_path}/workdir \\
              --cache=/tmp/.bazel-node-modules/{folder_path}/cache install \\
              --silent \\
              --ignore-scripts \\
              --no-update-notifier \\
              {packages};
          cp -R /tmp/.bazel-node-modules/{folder_path}/workdir/node_modules {folder_path}/;
        """.format(
            npm = ctx.executable._tool.path,
            packages = " ".join(packages),
            folder_path = folder_path,
        ),
        progress_message = "Installing node modules into %s" % tree.path,
    )

    return [DefaultInfo(files = depset([tree]))]

node_modules = rule(
    implementation = _impl,
    attrs = {
        "deps": attr.label_list(doc = "node package tars"),
        "for_subdirectory": attr.string(),
        "packages": attr.string_list(),
        "_tool": attr.label(
            executable = True,
            cfg = "host",
            allow_files = True,
            default = Label("@nodejs//:npm"),
        ),
    },
)
